Build type-safe, file-based React routing with TanStack Router. Supports client-side navigation, route loaders, and TanStack Query integration. Prevents 20 documented errors including validation structure loss, param parsing bugs, and SSR streaming crashes. Use when implementing file-based routing patterns, building SPAs with TypeScript routing, or troubleshooting devtools dependency errors, type safety issues, Vite bundling problems, or Docker deployment issues.
Type-safe, file-based routing for React SPAs with route-level data loading and TanStack Query integration
Last Updated: 2026-01-09 Version: @tanstack/react-router@1.146.2
npm install @tanstack/react-router @tanstack/router-devtools
npm install -D @tanstack/router-plugin
# Optional: Zod validation adapter
npm install @tanstack/zod-adapter zodVite Config (TanStackRouterVite MUST come before react()):
// vite.config.ts
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [TanStackRouterVite(), react()], // Order matters!
})File Structure:
src/routes/
├── __root.tsx → createRootRoute() with <Outlet />
├── index.tsx → createFileRoute('/')
└── posts.$postId.tsx → createFileRoute('/posts/$postId')App Setup:
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen' // Auto-generated by plugin
const router = createRouter({ routeTree })
<RouterProvider router={router} />Type-Safe Navigation (routes auto-complete, params typed):
<Link to="/posts/$postId" params={{ postId: '123' }} />
<Link to="/invalid" /> // ❌ TypeScript errorRoute Loaders (data fetching before render):
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => ({ post: await fetchPost(params.postId) }),
component: ({ useLoaderData }) => {
const { post } = useLoaderData() // Fully typed!
return <h1>{post.title}</h1>
},
})TanStack Query Integration (prefetch + cache):
const postOpts = (id: string) => queryOptions({
queryKey: ['posts', id],
queryFn: () => fetchPost(id),
})
export const Route = createFileRoute('/posts/$postId')({
loader: ({ context: { queryClient }, params }) =>
queryClient.ensureQueryData(postOpts(params.postId)),
component: () => {
const { postId } = Route.useParams()
const { data } = useQuery(postOpts(postId))
return <h1>{data.title}</h1>
},
})Programmatic route configuration when file-based conventions don't fit your needs:
Install: npm install @tanstack/virtual-file-routes
Vite Config:
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
virtualRouteConfig: './routes.ts', // Point to your routes file
}),
react(),
],
})routes.ts (define routes programmatically):
import { rootRoute, route, index, layout, physical } from '@tanstack/virtual-file-routes'
export const routes = rootRoute('root.tsx', [
index('home.tsx'),
route('/posts', 'posts/posts.tsx', [
index('posts/posts-home.tsx'),
route('$postId', 'posts/posts-detail.tsx'),
]),
layout('first', 'layout/first-layout.tsx', [
route('/nested', 'nested.tsx'),
]),
physical('/classic', 'file-based-subtree'), // Mix with file-based
])Use Cases: Custom route organization, mixing file-based and code-based, complex nested layouts.
Type-safe URL search params with runtime validation:
Basic Pattern (inline validation):
import { z } from 'zod'
export const Route = createFileRoute('/products')({
validateSearch: (search) => z.object({
page: z.number().catch(1),
filter: z.string().catch(''),
sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
}).parse(search),
})Recommended Pattern (Zod adapter with fallbacks):
import { zodValidator, fallback } from '@tanstack/zod-adapter'
import { z } from 'zod'
const searchSchema = z.object({
query: z.string().min(1).max(100),
page: fallback(z.number().int().positive(), 1),
sortBy: z.enum(['name', 'date', 'relevance']).optional(),
})
export const Route = createFileRoute('/search')({
validateSearch: zodValidator(searchSchema),
// Type-safe: Route.useSearch() returns typed params
})Why .catch() over .default(): Use .catch() to silently fix malformed params. Use .default() + errorComponent to show validation errors.
Handle errors at route level with typed error components:
Route-Level Error Handling:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
if (!post) throw new Error('Post not found')
return { post }
},
errorComponent: ({ error, reset }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={reset}>Retry</button>
</div>
),
})Default Error Component (global fallback):
const router = createRouter({
routeTree,
defaultErrorComponent: ({ error }) => (
<div className="error-page">
<h1>Something went wrong</h1>
<p>{error.message}</p>
</div>
),
})Not Found Handling:
export const Route = createFileRoute('/posts/$postId')({
notFoundComponent: () => <div>Post not found</div>,
})Protect routes before they load (no flash of protected content):
Single Route Protection:
import { redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.pathname }, // Save for post-login
})
}
},
})Protect Multiple Routes (layout route pattern):
// routes/(authenticated)/route.tsx - protects all children
export const Route = createFileRoute('/(authenticated)')({
beforeLoad: async ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
})Passing Auth Context (from React hooks):
// main.tsx - pass auth state to router
function App() {
const auth = useAuth() // Your auth hook
return (
<RouterProvider
router={router}
context={{ auth }} // Available in beforeLoad
/>
)
}This skill prevents 20 documented issues:
@tanstack/router-devtools-core not foundnpm install @tanstack/router-devtoolsIssue #2: Vite Plugin Order (CRITICAL)
routeTree.gen.ts missingIssue #3: Type Registration Missing
<Link to="..."> not typed, no autocompleterouteTree from ./routeTree.gen in main.tsx to register typesIssue #4: Loader Not Running
Route constant: export const Route = createFileRoute('/path')({ loader: ... })Issue #5: Memory Leak with TanStack Form (FIXED)
Issue #6: Virtual Routes Index/Layout Conflict
physical() in virtual routing_layout.tsx + _layout.index.tsxIssue #7: Search Params Type Inference
zodSearchValidatorzodValidator from @tanstack/zod-adapter insteadIssue #8: TanStack Start Validators on Reload
validateSearch not working on page reload in TanStack StartError: inputValidator Zod errors stringified, losing structure on client
Source: GitHub Issue #6428
Why It Happens: TanStack Start server function error serialization converts Zod issues array to JSON string in error.message, making it unusable without manual parsing.
Prevention:
// Server function with input validation
export const myFn = createServerFn({ method: 'POST' })
.inputValidator(z.object({
name: z.string().min(2),
age: z.number().min(18),
}))
.handler(async ({ data }) => data)
// Client: Workaround to parse stringified issues
try {
await mutation.mutate({ data: invalidData })
} catch (error) {
if (error.message.startsWith('[')) {
const issues = JSON.parse(error.message)
// Now can use structured error data
issues.forEach(issue => {
console.log(issue.path, issue.message)
})
}
}Official Status: Known issue, tracking PR for fix
Error: Params typed as parsed but returned as strings after navigation
Source: GitHub Issue #6385
Why It Happens: In v1.147.3+, match.params is no longer parsed when using strict: false. First render works correctly, but after navigation values are stored as strings instead of parsed types.
Prevention:
// Route with param parsing
export const Route = createFileRoute('/posts/$postId')({
params: {
parse: (params) => ({
postId: z.coerce.number().parse(params.postId),
}),
},
})
// Component: Use strict mode (default) for parsed params
function Component() {
const { postId } = useParams() // ✓ Parsed as number
// const { postId } = useParams({ strict: false }) // ✗ String!
// Or manually parse when using strict: false
const params = useParams({ strict: false })
const postId = Number(params.postId)
}Official Status: Known issue, workaround required
Error: notFoundComponent on pathless layout routes ignored
Source: GitHub Issue #6351, GitHub Issue #4065
Why It Happens: Pathless routes (e.g., routes/(authenticated)/route.tsx) don't render their notFoundComponent. Instead, the defaultNotFoundComponent from router config is triggered. This has been broken since April 2025.
Prevention:
// ✗ Doesn't work: notFoundComponent on pathless layout
export const Route = createFileRoute('/(authenticated)')({
beforeLoad: ({ context }) => {
if (!context.auth) throw redirect({ to: '/login' })
},
notFoundComponent: () => <div>Protected 404</div>, // Not rendered!
})
// ✓ Works: Define on child routes instead
export const Route = createFileRoute('/(authenticated)/dashboard')({
notFoundComponent: () => <div>Protected 404</div>,
})Official Status: Known issue, workaround required
Error: Rapid navigation aborts previous loader and renders errorComponent with undefined error
Source: GitHub Issue #6388
Why It Happens: Side effect introduced after PR #4570. When user rapidly navigates (e.g., clicking through list items), aborted fetch requests trigger errorComponent without passing the abort error.
Prevention:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params, abortController }) => {
await fetch(`/api/posts/${params.postId}`, {
signal: abortController.signal,
})
},
errorComponent: ({ error, reset }) => {
// Check for undefined error (aborted request)
if (!error) {
return null // Or show loading state
}
return <div>Error: {error.message}</div>
},
})Official Status: Known issue, workaround required
Error: Cannot read properties of null (reading 'useState') when running tests with Vitest
Source: GitHub Issue #6262, PR #6074
Why It Happens: TanStack Start's tanstackStart() plugin conflicts with Vitest's React hooks rendering. This is a known duplicate issue with a PR in progress.
Prevention:
// Temporary workaround: Comment out tanstackStart() for tests
// vite.config.ts
export default defineConfig({
plugins: [
// tanstackStart(), // Disable for tests
react(),
],
test: { environment: 'jsdom' },
})Official Status: PR #6074 in progress to fix
Error: Dev server crashes when route loader throws error without awaiting (using void instead of await)
Source: GitHub Issue #6200
Why It Happens: SSR streaming mode can't handle unawaited promise rejections. The error escapes the loader context and crashes the worker process.
Prevention:
// ✗ Wrong: void + throw crashes dev server
export const Route = createFileRoute('/posts')({
loader: async () => {
void fetch('/api/posts').then(r => {
throw new Error('boom') // Crashes!
})
},
})
// ✓ Correct: Always await or catch
export const Route = createFileRoute('/posts')({
loader: async () => {
try {
const data = await fetch('/api/posts')
return data
} catch (error) {
throw error // Caught by errorComponent
}
},
})Official Status: Known issue, workaround required
Error: Build step hangs when prerender.filter returns zero routes
Source: GitHub Issue #6425
Why It Happens: TanStack Start prerendering doesn't handle empty route sets gracefully - it waits indefinitely for routes that never come.
Prevention:
// ✗ Wrong: Empty filter causes hang
tanstackStart({
prerender: {
enabled: true,
filter: (route) => false, // No routes → hangs!
},
})
// ✓ Correct: Ensure at least one route or disable
tanstackStart({
prerender: {
enabled: true,
filter: (route) => route.path === '/' || route.path.startsWith('/posts'),
},
})
// Or temporarily disable
tanstackStart({
prerender: { enabled: false },
})Official Status: Known issue, workaround required
Error: Build fails in Docker with "Unable to connect" during prerender step Source: GitHub Issue #6275, PR #6305 Why It Happens: Vite preview server used for prerendering is not accessible in Docker environment.
Prevention:
// vite.config.ts - Make preview server accessible in Docker
export default defineConfig({
preview: {
host: true, // Bind to 0.0.0.0 instead of localhost
},
plugins: [
devtools(),
// nitro({ preset: "bun" }), // Remove temporarily if issues persist
tanstackStart(),
react(),
],
})Official Status: PR #6305 in progress
Error: Meta tags generated with incomplete data when head() runs before loader()
Source: GitHub Issue #6221
Why It Happens: The head() function can execute before the route loader() finishes, causing meta tags to use placeholder or undefined data.
Prevention:
// ✗ Wrong: loaderData may not be available yet
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData.post.title }, // May be undefined!
],
}),
})
// ✓ Correct: Explicitly await if needed
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
head: async ({ loaderData }) => {
await loaderData // Ensure loaded
return {
meta: [{ title: loaderData.post.title }],
}
},
})Official Status: Known issue, workaround required
Error: createLazyFileRoute automatically replaced with createFileRoute in virtual routes
Source: GitHub Issue #6396
Why It Happens: Virtual file routes are designed for automatic code splitting only. Manual lazy routes are not supported - the plugin silently replaces them.
Prevention:
// Virtual routes: Use automatic code splitting
// vite.config.ts
tanstackRouter({
target: 'react',
virtualRouteConfig: './routes.ts',
autoCodeSplitting: true, // Use automatic splitting
})
// Don't use createLazyFileRoute in virtual routes
// It will be replaced with createFileRoute automaticallyOfficial Status: By design (documented behavior)
Error: NavigateOptions type doesn't enforce required params like useNavigate() does
Source: TkDodo's Blog: The Beauty of TanStack Router
Why It Happens: Type definitions differ between runtime hook and type helper. NavigateOptions is less strict.
Prevention:
// ✗ Wrong: NavigateOptions doesn't catch missing params
const options: NavigateOptions = {
to: '/posts/$postId', // No TS error, but params required!
}
// ✓ Correct: Use useNavigate() return type
const navigate = useNavigate()
type NavigateFn = typeof navigate
// Now type-safe across all usagesVerified: Cross-referenced with TanStack Query maintainer analysis
Error: Routes fail to match when path defined without leading slash
Source: Official Debugging Guide
Why It Happens: Very common beginner mistake - using 'about' instead of '/about' causes route matching failures.
Prevention:
// ✗ Wrong: Missing leading slash
export const Route = createFileRoute('about')({ /* ... */ })
// ✓ Correct: Always start with /
export const Route = createFileRoute('/about')({ /* ... */ })Verified: Official documentation, common debugging issue
Vite Config (add @cloudflare/vite-plugin):
import { cloudflare } from '@cloudflare/vite-plugin'
export default defineConfig({
plugins: [TanStackRouterVite(), react(), cloudflare()],
})API Routes Pattern (fetch from Workers backend):
// Worker: functions/api/posts.ts
export async function onRequestGet({ env }) {
const { results } = await env.DB.prepare('SELECT * FROM posts').all()
return Response.json(results)
}
// Router: src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: async () => fetch('/api/posts').then(r => r.json()),
})Related Skills: tanstack-query (data fetching), react-hook-form-zod (form validation), cloudflare-worker-base (API backend), tailwind-v4-shadcn (UI)
Related Packages: @tanstack/zod-adapter (search validation), @tanstack/virtual-file-routes (programmatic routes)
Last verified: 2026-01-20 | Skill version: 2.0.0 | Changes: Added 12 new issues from community research (inputValidator structure loss, useParams parsing bug, pathless notFoundComponent, aborted loader errors, Vitest conflicts, SSR streaming crashes, Docker prerender issues, head/loader timing, virtual routes lazy loading limitation, NavigateOptions type inconsistency, leading slash common mistake). Increased error prevention from 8 to 20 documented issues.
fa91c34
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.